#
# Copyright (C) 2007-2009 Andrew Resch <andrewresch@gmail.com>
#
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
# the additional special exception to link portions of this program with the OpenSSL library.
# See LICENSE for more details.
#

"""Internal Torrent class

Attributes:
    LT_TORRENT_STATE_MAP (dict): Maps the torrent state from libtorrent to Deluge state.

"""

import logging
import os
import socket
import time
from typing import Optional
from urllib.parse import urlparse

from twisted.internet.defer import Deferred, DeferredList

import deluge.component as component
from deluge._libtorrent import lt
from deluge.common import decode_bytes
from deluge.configmanager import ConfigManager, get_config_dir
from deluge.core.authmanager import AUTH_LEVEL_ADMIN
from deluge.decorators import deprecated
from deluge.event import (
    TorrentFolderRenamedEvent,
    TorrentStateChangedEvent,
    TorrentTrackerStatusEvent,
)

log = logging.getLogger(__name__)

LT_TORRENT_STATE_MAP = {
    'queued_for_checking': 'Checking',
    'checking_files': 'Checking',
    'downloading_metadata': 'Downloading',
    'downloading': 'Downloading',
    'finished': 'Seeding',
    'seeding': 'Seeding',
    'allocating': 'Allocating',
    'checking_resume_data': 'Checking',
}


def sanitize_filepath(filepath, folder=False):
    """Returns a sanitized filepath to pass to libtorrent rename_file().

    The filepath will have backslashes substituted along with whitespace
    padding and duplicate slashes stripped.

    Args:
        folder (bool): A trailing slash is appended to the returned filepath.
    """

    def clean_filename(filename):
        """Strips whitespace and discards dotted filenames"""
        filename = filename.strip()
        if filename.replace('.', '') == '':
            return ''
        return filename

    if '\\' in filepath or '/' in filepath:
        folderpath = filepath.replace('\\', '/').split('/')
        folderpath = [clean_filename(x) for x in folderpath]
        newfilepath = '/'.join([path for path in folderpath if path])
    else:
        newfilepath = clean_filename(filepath)

    if folder is True:
        newfilepath += '/'

    return newfilepath


def convert_lt_files(files):
    """Indexes and decodes files from libtorrent get_files().

    Args:
        files (file_storage): The libtorrent torrent files.

    Returns:
        list of dict: The files.

        The format for the file dict::

            {
                "index": int,
                "path": str,
                "size": int,
                "offset": int
            }
    """
    filelist = []
    for index in range(files.num_files()):
        try:
            file_path = files.file_path(index).decode('utf8')
        except AttributeError:
            file_path = files.file_path(index)

        filelist.append(
            {
                'index': index,
                'path': file_path.replace('\\', '/'),
                'size': files.file_size(index),
                'offset': files.file_offset(index),
            }
        )

    return filelist


class TorrentOptions(dict):
    """TorrentOptions create a dict of the torrent options.

    Attributes:
        add_paused (bool): Add the torrrent in a paused state.
        auto_managed (bool): Set torrent to auto managed mode, i.e. will be started or queued automatically.
        download_location (str): The path for the torrent data to be stored while downloading.
        file_priorities (list of int): The priority for files in torrent, range is [0..7] however
            only [0, 1, 4, 7] are normally used and correspond to [Skip, Low, Normal, High]
        mapped_files (dict): A mapping of the renamed filenames in 'index:filename' pairs.
        max_connections (int): Sets maximum number of connections this torrent will open.
            This must be at least 2. The default is unlimited (-1).
        max_download_speed (float): Will limit the download bandwidth used by this torrent to the
            limit you set.The default is unlimited (-1) but will not exceed global limit.
        max_upload_slots (int): Sets the maximum number of peers that are
            unchoked at the same time on this torrent. This defaults to infinite (-1).
        max_upload_speed (float): Will limit the upload bandwidth used by this torrent to the limit
            you set. The default is unlimited (-1) but will not exceed global limit.
        move_completed (bool): Move the torrent when downloading has finished.
        move_completed_path (str): The path to move torrent to when downloading has finished.
        name (str): The display name of the torrent.
        owner (str): The user this torrent belongs to.
        pre_allocate_storage (bool): When adding the torrent should all files be pre-allocated.
        prioritize_first_last_pieces (bool): Prioritize the first and last pieces in the torrent.
        remove_at_ratio (bool): Remove the torrent when it has reached the stop_ratio.
        seed_mode (bool): Assume that all files are present for this torrent (Only used when adding a torent).
        sequential_download (bool): Download the pieces of the torrent in order.
        shared (bool): Enable the torrent to be seen by other Deluge users.
        stop_at_ratio (bool): Stop the torrent when it has reached stop_ratio.
        stop_ratio (float): The seeding ratio to stop (or remove) the torrent at.
        super_seeding (bool): Enable super seeding/initial seeding.
    """

    def __init__(self):
        super().__init__()
        config = ConfigManager('core.conf').config
        options_conf_map = {
            'add_paused': 'add_paused',
            'auto_managed': 'auto_managed',
            'download_location': 'download_location',
            'max_connections': 'max_connections_per_torrent',
            'max_download_speed': 'max_download_speed_per_torrent',
            'max_upload_slots': 'max_upload_slots_per_torrent',
            'max_upload_speed': 'max_upload_speed_per_torrent',
            'move_completed': 'move_completed',
            'move_completed_path': 'move_completed_path',
            'pre_allocate_storage': 'pre_allocate_storage',
            'prioritize_first_last_pieces': 'prioritize_first_last_pieces',
            'remove_at_ratio': 'remove_seed_at_ratio',
            'sequential_download': 'sequential_download',
            'shared': 'shared',
            'stop_at_ratio': 'stop_seed_at_ratio',
            'stop_ratio': 'stop_seed_ratio',
            'super_seeding': 'super_seeding',
        }
        for opt_k, conf_k in options_conf_map.items():
            self[opt_k] = config[conf_k]
        self['file_priorities'] = []
        self['mapped_files'] = {}
        self['name'] = ''
        self['owner'] = ''
        self['seed_mode'] = False


class TorrentError:
    def __init__(self, error_message, was_paused=False, restart_to_resume=False):
        self.error_message = error_message
        self.was_paused = was_paused
        self.restart_to_resume = restart_to_resume


class Torrent:
    """Torrent holds information about torrents added to the libtorrent session.

    Args:
        handle: The libtorrent torrent handle.
        options (dict): The torrent options.
        state (TorrentState): The torrent state.
        filename (str): The filename of the torrent file.
        magnet (str): The magnet URI.

    Attributes:
        torrent_id (str): The torrent_id for this torrent
        handle: Holds the libtorrent torrent handle
        magnet (str): The magnet URI used to add this torrent (if available).
        status: Holds status info so that we don"t need to keep getting it from libtorrent.
        torrent_info: store the torrent info.
        has_metadata (bool): True if the metadata for the torrent is available, False otherwise.
        status_funcs (dict): The function mappings to get torrent status
        prev_status (dict): Previous status dicts returned for this torrent. We use this to return
            dicts that only contain changes from the previous.
            {session_id: status_dict, ...}
        waiting_on_folder_rename (list of dict): A list of Deferreds for file indexes we're waiting for file_rename
            alerts on. This is so we can send one folder_renamed signal instead of multiple file_renamed signals.
            [{index: Deferred, ...}, ...]
        options (dict): The torrent options.
        filename (str): The filename of the torrent file in case it is required.
        is_finished (bool): Keep track if torrent is finished to prevent some weird things on state load.
        statusmsg (str): Status message holds error/extra info about the torrent.
        state (str): The torrent's state
        trackers (list of dict): The torrent's trackers
        tracker_status (str): Status message of currently connected tracker
        tracker_host (str): Hostname of the currently connected tracker
        forcing_recheck (bool): Keep track if we're forcing a recheck of the torrent
        forcing_recheck_paused (bool): Keep track if we're forcing a recheck of the torrent so that
            we can re-pause it after its done if necessary
        forced_error (TorrentError): Keep track if we have forced this torrent to be in Error state.
    """

    def __init__(self, handle, options, state=None, filename=None, magnet=None):
        self.torrent_id = str(handle.info_hash())
        if log.isEnabledFor(logging.DEBUG):
            log.debug('Creating torrent object %s', self.torrent_id)

        # Get the core config
        self.config = ConfigManager('core.conf')
        self.rpcserver = component.get('RPCServer')

        self.handle = handle

        self.magnet = magnet
        self._status: Optional['lt.torrent_status'] = None
        self._status_last_update: float = 0.0

        self.torrent_info = self.handle.torrent_file()
        self.has_metadata = self.status.has_metadata

        self.options = TorrentOptions()
        self.options.update(options)

        # Load values from state if we have it
        if state:
            self.set_trackers(state.trackers)
            self.is_finished = state.is_finished
            self.filename = state.filename
        else:
            self.set_trackers()
            self.is_finished = False
            self.filename = filename

        if not self.filename:
            self.filename = ''

        self.forced_error = None
        self.statusmsg = None
        self.state = None
        self.moving_storage_dest_path = None
        self.tracker_status = ''
        self.tracker_host = None
        self.forcing_recheck = False
        self.forcing_recheck_paused = False
        self.status_funcs = None
        self.prev_status = {}
        self.waiting_on_folder_rename = []

        self._create_status_funcs()
        self.set_options(self.options)
        self.update_state()

        if log.isEnabledFor(logging.DEBUG):
            log.debug('Torrent object created.')

    def _set_handle_flags(self, flag: lt.torrent_flags, set_flag: bool):
        """set or unset a flag to the lt handle

        Args:
            flag (lt.torrent_flags): the flag to set/unset
            set_flag (bool): True for setting the flag, False for unsetting it
        """
        if set_flag:
            self.handle.set_flags(flag)
        else:
            self.handle.unset_flags(flag)

    def on_metadata_received(self):
        """Process the metadata received alert for this torrent"""
        self.has_metadata = True
        self.torrent_info = self.handle.get_torrent_info()
        if self.options['prioritize_first_last_pieces']:
            self.set_prioritize_first_last_pieces(True)
        self.write_torrentfile()

    # --- Options methods ---
    def set_options(self, options):
        """Set the torrent options.

        Args:
            options (dict): Torrent options, see TorrentOptions class for valid keys.
        """

        # Skip set_prioritize_first_last if set_file_priorities is in options as it also calls the method.
        if 'file_priorities' in options and 'prioritize_first_last_pieces' in options:
            self.options['prioritize_first_last_pieces'] = options.pop(
                'prioritize_first_last_pieces'
            )

        for key, value in options.items():
            if key in self.options:
                options_set_func = getattr(self, 'set_' + key, None)
                if options_set_func:
                    options_set_func(value)
                else:
                    # Update config options that do not have funcs
                    self.options[key] = value

    def get_options(self):
        """Get the torrent options.

        Returns:
            dict: the torrent options.
        """
        return self.options

    def set_max_connections(self, max_connections):
        """Sets maximum number of connections this torrent will open.

        Args:
            max_connections (int): Maximum number of connections

        Note:
            The minimum value for handle.max_connections is 2 (or -1 for unlimited connections).
            This is enforced by libtorrent and values 0 or 1 raise an assert with lt debug builds.
        """

        if max_connections == 0:
            max_connections = -1
        elif max_connections == 1:
            max_connections = 2

        self.options['max_connections'] = max_connections
        self.handle.set_max_connections(max_connections)

    def set_max_upload_slots(self, max_slots):
        """Sets maximum number of upload slots for this torrent.

        Args:
            max_slots (int): Maximum upload slots
        """
        self.options['max_upload_slots'] = max_slots
        self.handle.set_max_uploads(max_slots)

    def set_max_upload_speed(self, m_up_speed):
        """Sets maximum upload speed for this torrent.

        Args:
            m_up_speed (float): Maximum upload speed in KiB/s.
        """
        self.options['max_upload_speed'] = m_up_speed
        if m_up_speed < 0:
            value = -1
        else:
            value = int(m_up_speed * 1024)
        self.handle.set_upload_limit(value)

    def set_max_download_speed(self, m_down_speed):
        """Sets maximum download speed for this torrent.

        Args:
            m_down_speed (float): Maximum download speed in KiB/s.
        """
        self.options['max_download_speed'] = m_down_speed
        if m_down_speed < 0:
            value = -1
        else:
            value = int(m_down_speed * 1024)
        self.handle.set_download_limit(value)

    @deprecated
    def set_prioritize_first_last(self, prioritize):
        """Deprecated: Use set_prioritize_first_last_pieces."""
        self.set_prioritize_first_last_pieces(prioritize)

    def set_prioritize_first_last_pieces(self, prioritize):
        """Prioritize the first and last pieces in the torrent.

        Args:
            prioritize (bool): Prioritize the first and last pieces.

        """
        if not self.has_metadata:
            return

        self.options['prioritize_first_last_pieces'] = prioritize
        if not prioritize:
            # If we are turning off this option, call set_file_priorities to
            # reset all the piece priorities
            self.set_file_priorities(self.options['file_priorities'])
            return

        # A list of priorities for each piece in the torrent
        priorities = self.handle.get_piece_priorities()

        def get_file_piece(idx, byte_offset):
            return self.torrent_info.map_file(idx, byte_offset, 0).piece

        for idx in range(self.torrent_info.num_files()):
            file_size = self.torrent_info.files().file_size(idx)
            two_percent_bytes = int(0.02 * file_size)
            # Get the pieces for the byte offsets
            first_start = get_file_piece(idx, 0)
            first_end = get_file_piece(idx, two_percent_bytes) + 1
            last_start = get_file_piece(idx, file_size - two_percent_bytes)
            last_end = get_file_piece(idx, max(file_size - 1, 0)) + 1

            # Set the pieces in first and last ranges to priority 7
            # if they are not marked as do not download
            priorities[first_start:first_end] = [
                p and 7 for p in priorities[first_start:first_end]
            ]
            priorities[last_start:last_end] = [
                p and 7 for p in priorities[last_start:last_end]
            ]

        # Setting the priorites for all the pieces of this torrent
        self.handle.prioritize_pieces(priorities)

    def set_sequential_download(self, sequential):
        """Sets whether to download the pieces of the torrent in order.

        Args:
            sequential (bool): Enable sequential downloading.
        """
        self.options['sequential_download'] = sequential
        self._set_handle_flags(
            flag=lt.torrent_flags.sequential_download,
            set_flag=sequential,
        )

    def set_auto_managed(self, auto_managed):
        """Set auto managed mode, i.e. will be started or queued automatically.

        Args:
            auto_managed (bool): Enable auto managed.
        """
        self.options['auto_managed'] = auto_managed
        if not (self.status.paused and not self.status.auto_managed):
            self._set_handle_flags(
                flag=lt.torrent_flags.auto_managed,
                set_flag=auto_managed,
            )
            self.update_state()

    def set_super_seeding(self, super_seeding):
        """Set super seeding/initial seeding.

        Args:
            super_seeding (bool): Enable super seeding.
        """
        self.options['super_seeding'] = super_seeding
        self._set_handle_flags(
            flag=lt.torrent_flags.super_seeding,
            set_flag=super_seeding,
        )

    def set_stop_ratio(self, stop_ratio):
        """The seeding ratio to stop (or remove) the torrent at.

        Args:
            stop_ratio (float): The seeding ratio.
        """
        self.options['stop_ratio'] = stop_ratio

    def set_stop_at_ratio(self, stop_at_ratio):
        """Stop the torrent when it has reached stop_ratio.

        Args:
            stop_at_ratio (bool): Stop the torrent.
        """
        self.options['stop_at_ratio'] = stop_at_ratio

    def set_remove_at_ratio(self, remove_at_ratio):
        """Remove the torrent when it has reached the stop_ratio.

        Args:
            remove_at_ratio (bool): Remove the torrent.
        """
        self.options['remove_at_ratio'] = remove_at_ratio

    def set_move_completed(self, move_completed):
        """Set whether to move the torrent when downloading has finished.

        Args:
            move_completed (bool): Move the torrent.

        """
        self.options['move_completed'] = move_completed

    def set_move_completed_path(self, move_completed_path):
        """Set the path to move torrent to when downloading has finished.

        Args:
            move_completed_path (str): The move path.
        """
        self.options['move_completed_path'] = move_completed_path

    def set_file_priorities(self, file_priorities):
        """Sets the file priotities.

        Args:
            file_priorities (list of int): List of file priorities.
        """
        if not self.has_metadata:
            return

        if log.isEnabledFor(logging.DEBUG):
            log.debug(
                'Setting %s file priorities to: %s', self.torrent_id, file_priorities
            )

        if file_priorities and len(file_priorities) == len(self.get_files()):
            self.handle.prioritize_files(file_priorities)
        else:
            log.debug('Unable to set new file priorities.')
            file_priorities = self.handle.get_file_priorities()

        if 0 in self.options['file_priorities']:
            # Previously marked a file 'skip' so check for any 0's now >0.
            for index, priority in enumerate(self.options['file_priorities']):
                if priority == 0 and file_priorities[index] > 0:
                    # Changed priority from skip to download so update state.
                    self.is_finished = False
                    self.update_state()
                    break

        # Store the priorities.
        self.options['file_priorities'] = file_priorities

        # Set the first/last priorities if needed.
        if self.options['prioritize_first_last_pieces']:
            self.set_prioritize_first_last_pieces(True)

    @deprecated
    def set_save_path(self, download_location):
        """Deprecated: Use set_download_location."""
        self.set_download_location(download_location)

    def set_download_location(self, download_location):
        """The location for downloading torrent data."""
        self.options['download_location'] = download_location

    def set_owner(self, account):
        """Sets the owner of this torrent.

        Args:
            account (str): The new owner account name.

        Notes:
            Only a user with admin level auth can change this value.

        """

        if self.rpcserver.get_session_auth_level() == AUTH_LEVEL_ADMIN:
            self.options['owner'] = account

    # End Options methods #

    def set_trackers(self, trackers=None):
        """Sets the trackers for this torrent.

        Args:
            trackers (list of dicts): A list of trackers.
        """
        if trackers is None:
            self.trackers = list(self.handle.trackers())
            self.tracker_host = None
            return

        if log.isEnabledFor(logging.DEBUG):
            log.debug('Setting trackers for %s: %s', self.torrent_id, trackers)

        tracker_list = []

        for tracker in trackers:
            new_entry = lt.announce_entry(str(tracker['url']))
            new_entry.tier = tracker['tier']
            tracker_list.append(new_entry)
        self.handle.replace_trackers(tracker_list)

        # Print out the trackers
        if log.isEnabledFor(logging.DEBUG):
            log.debug('Trackers set for %s:', self.torrent_id)
            for tracker in self.handle.trackers():
                log.debug(' [tier %s]: %s', tracker['tier'], tracker['url'])
        # Set the tracker list in the torrent object
        self.trackers = trackers
        if len(trackers) > 0:
            # Force a re-announce if there is at least 1 tracker
            self.force_reannounce()
        self.tracker_host = None

    def set_tracker_status(self, status):
        """Sets the tracker status.

        Args:
            status (str): The tracker status.

        Emits:
            TorrentTrackerStatusEvent upon tracker status change.

        """

        self.tracker_host = None

        if self.tracker_status != status:
            self.tracker_status = status
            component.get('EventManager').emit(
                TorrentTrackerStatusEvent(self.torrent_id, self.tracker_status)
            )

    def merge_trackers(self, torrent_info):
        """Merges new trackers in torrent_info into torrent"""
        log.info(
            'Adding any new trackers to torrent (%s) already in session...',
            self.torrent_id,
        )
        if not torrent_info:
            return
        # Don't merge trackers if either torrent has private flag set.
        if torrent_info.priv() or self.get_status(['private'])['private']:
            log.info('Adding trackers aborted: Torrent has private flag set.')
        else:
            for tracker in torrent_info.trackers():
                self.handle.add_tracker({'url': tracker.url, 'tier': tracker.tier})
            # Update torrent.trackers from libtorrent handle.
            self.set_trackers()

    def update_state(self):
        """Updates the state, based on libtorrent's torrent state"""
        status = self.get_lt_status()
        session_paused = component.get('Core').session.is_paused()
        old_state = self.state
        self.set_status_message()
        status_error = status.errc.message() if status.errc.value() else ''

        if self.forced_error:
            self.state = 'Error'
            self.set_status_message(self.forced_error.error_message)
        elif status_error:
            self.state = 'Error'
            # auto-manage status will be reverted upon resuming.
            self._set_handle_flags(
                flag=lt.torrent_flags.auto_managed,
                set_flag=False,
            )
            self.set_status_message(decode_bytes(status_error))
        elif status.moving_storage:
            self.state = 'Moving'
        elif not session_paused and status.paused and status.auto_managed:
            self.state = 'Queued'
        elif session_paused or status.paused:
            self.state = 'Paused'
        else:
            self.state = LT_TORRENT_STATE_MAP.get(str(status.state), str(status.state))

        if self.state != old_state:
            component.get('EventManager').emit(
                TorrentStateChangedEvent(self.torrent_id, self.state)
            )

        if log.isEnabledFor(logging.DEBUG):
            log.debug(
                'State from lt was: %s | Session is paused: %s\nTorrent state set from "%s" to "%s" (%s)',
                'error' if status_error else status.state,
                session_paused,
                old_state,
                self.state,
                self.torrent_id,
            )
            if self.forced_error:
                log.debug(
                    'Torrent Error state message: %s', self.forced_error.error_message
                )

    def set_status_message(self, message=None):
        """Sets the torrent status message.

        Calling method without a message will reset the message to 'OK'.

        Args:
            message (str, optional): The status message.

        """
        if not message:
            message = 'OK'
        self.statusmsg = message

    def force_error_state(self, message, restart_to_resume=True):
        """Forces the torrent into an error state.

        For setting an error state not covered by libtorrent.

        Args:
            message (str): The error status message.
            restart_to_resume (bool, optional): Prevent resuming clearing the error, only restarting
                session can resume.
        """
        status = self.get_lt_status()
        self._set_handle_flags(
            flag=lt.torrent_flags.auto_managed,
            set_flag=False,
        )
        self.forced_error = TorrentError(message, status.paused, restart_to_resume)
        if not status.paused:
            self.handle.pause()
        self.update_state()

    def clear_forced_error_state(self, update_state=True):
        if not self.forced_error:
            return

        if self.forced_error.restart_to_resume:
            log.error('Restart deluge to clear this torrent error')

        if not self.forced_error.was_paused and self.options['auto_managed']:
            self._set_handle_flags(
                flag=lt.torrent_flags.auto_managed,
                set_flag=True,
            )
        self.forced_error = None
        self.set_status_message('OK')
        if update_state:
            self.update_state()

    def get_eta(self):
        """Get the ETA for this torrent.

        Returns:
            int: The ETA in seconds.

        """
        status = self.status
        eta = 0
        if (
            self.is_finished
            and self.options['stop_at_ratio']
            and status.upload_payload_rate
        ):
            # We're a seed, so calculate the time to the 'stop_share_ratio'
            eta = (
                int(status.all_time_download * self.options['stop_ratio'])
                - status.all_time_upload
            ) // status.upload_payload_rate
        elif status.download_payload_rate:
            left = status.total_wanted - status.total_wanted_done
            if left > 0:
                eta = left // status.download_payload_rate

        # Limit to 1 year, avoid excessive values and prevent GTK int overflow.
        return eta if eta < 31557600 else -1

    def get_ratio(self):
        """Get the ratio of upload/download for this torrent.

        Returns:
            float: The ratio or -1.0 (for infinity).

        """
        if self.status.total_done > 0:
            return self.status.all_time_upload / self.status.total_done
        else:
            return -1.0

    def get_files(self):
        """Get the files this torrent contains.

        Returns:
            list of dict: The files.

        """
        if not self.has_metadata:
            return []

        files = self.torrent_info.files()
        return convert_lt_files(files)

    def get_orig_files(self):
        """Get the original filenames of files in this torrent.

        Returns:
            list of dict: The files with original filenames.

        """
        if not self.has_metadata:
            return []

        files = self.torrent_info.orig_files()
        return convert_lt_files(files)

    def get_peers(self):
        """Get the peers for this torrent.

        A list of peers and various information about them.

        Returns:
            list of dict: The peers.

            The format for the peer dict::

                {
                    "client": str,
                    "country": str,
                    "down_speed": int,
                    "ip": str,
                    "progress": float,
                    "seed": bool,
                    "up_speed": int
                }
        """
        ret = []
        peers = self.handle.get_peer_info()

        for peer in peers:
            # We do not want to report peers that are half-connected
            if peer.flags & peer.connecting or peer.flags & peer.handshake:
                continue

            try:
                client = decode_bytes(peer.client)
            except UnicodeDecodeError:
                # libtorrent on Py3 can raise UnicodeDecodeError for peer_info.client
                client = 'unknown'

            try:
                country = component.get('Core').geoip_instance.country_code_by_addr(
                    peer.ip[0]
                )
            except AttributeError:
                country = ''
            else:
                try:
                    country = ''.join(
                        [char if char.isalpha() else ' ' for char in country]
                    )
                except TypeError:
                    country = ''

            ret.append(
                {
                    'client': client,
                    'country': country,
                    'down_speed': peer.payload_down_speed,
                    'ip': f'{peer.ip[0]}:{peer.ip[1]}',
                    'progress': peer.progress,
                    'seed': peer.flags & peer.seed,
                    'up_speed': peer.payload_up_speed,
                }
            )

        return ret

    def get_queue_position(self):
        """Get the torrents queue position

        Returns:
            int: queue position
        """
        return self.handle.queue_position()

    def get_file_priorities(self):
        """Return the file priorities"""
        if not self.handle.status().has_metadata:
            return []

        if not self.options['file_priorities']:
            # Ensure file_priorities option is populated.
            self.set_file_priorities([])

        return self.options['file_priorities']

    def get_file_progress(self):
        """Calculates the file progress as a percentage.

        Returns:
            list of floats: The file progress (0.0 -> 1.0), empty list if n/a.
        """
        if not self.has_metadata:
            return []

        try:
            files_progresses = zip(
                self.handle.file_progress(), self.torrent_info.files()
            )
        except Exception:
            # Handle libtorrent >=2.0.0,<=2.0.4 file_progress error
            files_progresses = zip(iter(lambda: 0, 1), self.torrent_info.files())

        return [
            progress / _file.size if _file.size else 0.0
            for progress, _file in files_progresses
        ]

    def get_tracker_host(self):
        """Get the hostname of the currently connected tracker.

        If no tracker is connected, it uses the 1st tracker.

        Returns:
            str: The tracker host
        """
        if self.tracker_host:
            return self.tracker_host

        tracker = self.status.current_tracker
        if not tracker and self.trackers:
            tracker = self.trackers[0]['url']

        if tracker:
            url = urlparse(tracker.replace('udp://', 'http://'))
            if hasattr(url, 'hostname'):
                host = url.hostname or 'DHT'
                # Check if hostname is an IP address and just return it if that's the case
                try:
                    socket.inet_aton(host)
                except OSError:
                    pass
                else:
                    # This is an IP address because an exception wasn't raised
                    return url.hostname

                parts = host.split('.')
                if len(parts) > 2:
                    if parts[-2] in ('co', 'com', 'net', 'org') or parts[-1] == 'uk':
                        host = '.'.join(parts[-3:])
                    else:
                        host = '.'.join(parts[-2:])
                self.tracker_host = host
                return host
        return ''

    def get_magnet_uri(self):
        """Returns a magnet URI for this torrent"""
        return lt.make_magnet_uri(self.handle)

    def get_name(self):
        """The name of the torrent (distinct from the filenames).

        Note:
            Can be manually set in options through `name` key. If the key is
            reset to empty string "" it will return the original torrent name.

        Returns:
            str: the name of the torrent.

        """
        if self.options['name']:
            return self.options['name']

        if self.has_metadata:
            # Use the top-level folder as torrent name.
            filename = decode_bytes(self.torrent_info.files().file_path(0))
            name = filename.replace('\\', '/', 1).split('/', 1)[0]
        else:
            name = decode_bytes(self.handle.status().name)

        if not name:
            name = self.torrent_id

        return name

    def get_progress(self):
        """The progress of this torrent's current task.

        Returns:
            float: The progress percentage (0 to 100).

        """

        def get_size(files, path):
            """Returns total size of 'files' currently located in 'path'"""
            files = [os.path.join(path, f) for f in files]
            return sum(os.stat(f).st_size for f in files if os.path.exists(f))

        if self.state == 'Error':
            progress = 100.0
        elif self.state == 'Moving':
            # Check if torrent has downloaded any data yet.
            if self.status.total_done:
                torrent_files = [f['path'] for f in self.get_files()]
                dest_path_size = get_size(torrent_files, self.moving_storage_dest_path)
                progress = dest_path_size / self.status.total_done * 100
            else:
                progress = 100.0
        else:
            progress = self.status.progress * 100

        return progress

    def get_time_since_transfer(self):
        """The time since either upload/download from peers"""
        time_since = (self.status.time_since_download, self.status.time_since_upload)
        try:
            return min(x for x in time_since if x != -1)
        except ValueError:
            return -1

    def get_status(self, keys, diff=False, update=False, all_keys=False):
        """Returns the status of the torrent based on the keys provided

        Args:
            keys (list of str): the keys to get the status on
            diff (bool): Will return a diff of the changes since the last
                call to get_status based on the session_id
            update (bool): If True the status will be updated from libtorrent
                if False, the cached values will be returned
            all_keys (bool): If True return all keys while ignoring the keys param
                if False, return only the requested keys

        Returns:
            dict: a dictionary of the status keys and their values
        """
        if update:
            self.get_lt_status()

        if all_keys:
            keys = list(self.status_funcs)

        status_dict = {}

        for key in keys:
            status_dict[key] = self.status_funcs[key]()

        if diff:
            session_id = self.rpcserver.get_session_id()
            if session_id in self.prev_status:
                # We have a previous status dict, so lets make a diff
                status_diff = {}
                for key, value in status_dict.items():
                    if key in self.prev_status[session_id]:
                        if value != self.prev_status[session_id][key]:
                            status_diff[key] = value
                    else:
                        status_diff[key] = value

                self.prev_status[session_id] = status_dict
                return status_diff

            self.prev_status[session_id] = status_dict
            return status_dict

        return status_dict

    def get_lt_status(self) -> 'lt.torrent_status':
        """Get the torrent status fresh, not from cache.

        This should be used when a guaranteed fresh status is needed rather than
        `torrent.handle.status()` because it will update the cache as well.
        """
        self.status = self.handle.status()
        return self.status

    @property
    def status(self) -> 'lt.torrent_status':
        """Cached copy of the libtorrent status for this torrent.

        If it has not been updated within the last five seconds, it will be
        automatically refreshed.
        """
        if self._status_last_update < (time.time() - 5):
            self.status = self.handle.status()
        return self._status

    @status.setter
    def status(self, status: 'lt.torrent_status') -> None:
        """Updates the cached status.

        Args:
            status: a libtorrent torrent status
        """
        self._status = status
        self._status_last_update = time.time()

    def _create_status_funcs(self):
        """Creates the functions for getting torrent status"""
        self.status_funcs = {
            'active_time': lambda: self.status.active_time,
            'seeding_time': lambda: self.status.seeding_time,
            'finished_time': lambda: self.status.finished_time,
            'all_time_download': lambda: self.status.all_time_download,
            'storage_mode': lambda: self.status.storage_mode.name.split('_')[
                2
            ],  # sparse or allocate
            'distributed_copies': lambda: max(0.0, self.status.distributed_copies),
            'download_payload_rate': lambda: self.status.download_payload_rate,
            'file_priorities': self.get_file_priorities,
            'hash': lambda: self.torrent_id,
            'auto_managed': lambda: self.options['auto_managed'],
            'is_auto_managed': lambda: self.options['auto_managed'],
            'is_finished': lambda: self.is_finished,
            'max_connections': lambda: self.options['max_connections'],
            'max_download_speed': lambda: self.options['max_download_speed'],
            'max_upload_slots': lambda: self.options['max_upload_slots'],
            'max_upload_speed': lambda: self.options['max_upload_speed'],
            'message': lambda: self.statusmsg,
            'move_on_completed_path': lambda: self.options[
                'move_completed_path'
            ],  # Deprecated: move_completed_path
            'move_on_completed': lambda: self.options[
                'move_completed'
            ],  # Deprecated: Use move_completed
            'move_completed_path': lambda: self.options['move_completed_path'],
            'move_completed': lambda: self.options['move_completed'],
            'next_announce': lambda: self.status.next_announce.seconds,
            'num_peers': lambda: self.status.num_peers - self.status.num_seeds,
            'num_seeds': lambda: self.status.num_seeds,
            'owner': lambda: self.options['owner'],
            'paused': lambda: self.status.paused,
            'prioritize_first_last': lambda: self.options[
                'prioritize_first_last_pieces'
            ],
            # Deprecated: Use prioritize_first_last_pieces
            'prioritize_first_last_pieces': lambda: self.options[
                'prioritize_first_last_pieces'
            ],
            'sequential_download': lambda: self.options['sequential_download'],
            'progress': self.get_progress,
            'shared': lambda: self.options['shared'],
            'remove_at_ratio': lambda: self.options['remove_at_ratio'],
            'save_path': lambda: self.options[
                'download_location'
            ],  # Deprecated: Use download_location
            'download_location': lambda: self.options['download_location'],
            'seeds_peers_ratio': lambda: -1.0
            if self.status.num_incomplete == 0
            # Use -1.0 to signify infinity
            else (self.status.num_complete / self.status.num_incomplete),
            'seed_rank': lambda: self.status.seed_rank,
            'state': lambda: self.state,
            'stop_at_ratio': lambda: self.options['stop_at_ratio'],
            'stop_ratio': lambda: self.options['stop_ratio'],
            'time_added': lambda: self.status.added_time,
            'total_done': lambda: self.status.total_done,
            'total_payload_download': lambda: self.status.total_payload_download,
            'total_payload_upload': lambda: self.status.total_payload_upload,
            'total_peers': lambda: self.status.num_incomplete,
            'total_seeds': lambda: self.status.num_complete,
            'total_uploaded': lambda: self.status.all_time_upload,
            'total_wanted': lambda: self.status.total_wanted,
            'total_remaining': lambda: self.status.total_wanted
            - self.status.total_wanted_done,
            'tracker': lambda: self.status.current_tracker,
            'tracker_host': self.get_tracker_host,
            'trackers': lambda: self.trackers,
            'tracker_status': lambda: self.tracker_status,
            'upload_payload_rate': lambda: self.status.upload_payload_rate,
            'comment': lambda: decode_bytes(self.torrent_info.comment())
            if self.has_metadata
            else '',
            'creator': lambda: decode_bytes(self.torrent_info.creator())
            if self.has_metadata
            else '',
            'num_files': lambda: self.torrent_info.num_files()
            if self.has_metadata
            else 0,
            'num_pieces': lambda: self.torrent_info.num_pieces()
            if self.has_metadata
            else 0,
            'piece_length': lambda: self.torrent_info.piece_length()
            if self.has_metadata
            else 0,
            'private': lambda: self.torrent_info.priv() if self.has_metadata else False,
            'total_size': lambda: self.torrent_info.total_size()
            if self.has_metadata
            else 0,
            'eta': self.get_eta,
            'file_progress': self.get_file_progress,
            'files': self.get_files,
            'orig_files': self.get_orig_files,
            'is_seed': lambda: self.status.is_seeding,
            'peers': self.get_peers,
            'queue': lambda: self.status.queue_position,
            'ratio': self.get_ratio,
            'completed_time': lambda: self.status.completed_time,
            'last_seen_complete': lambda: self.status.last_seen_complete,
            'name': self.get_name,
            'pieces': self._get_pieces_info,
            'seed_mode': lambda: self.status.seed_mode,
            'super_seeding': lambda: self.status.super_seeding,
            'time_since_download': lambda: self.status.time_since_download,
            'time_since_upload': lambda: self.status.time_since_upload,
            'time_since_transfer': self.get_time_since_transfer,
        }

    def pause(self):
        """Pause this torrent.

        Returns:
            bool: True is successful, otherwise False.

        """
        # Turn off auto-management so the torrent will not be unpaused by lt queueing
        self._set_handle_flags(
            flag=lt.torrent_flags.auto_managed,
            set_flag=False,
        )
        if self.state == 'Error':
            log.debug('Unable to pause torrent while in Error state')
        elif self.status.paused:
            # This torrent was probably paused due to being auto managed by lt
            # Since we turned auto_managed off, we should update the state which should
            # show it as 'Paused'.  We need to emit a torrent_paused signal because
            # the torrent_paused alert from libtorrent will not be generated.
            self.update_state()
            component.get('EventManager').emit(
                TorrentStateChangedEvent(self.torrent_id, 'Paused')
            )
        else:
            try:
                self.handle.pause()
            except RuntimeError as ex:
                log.debug('Unable to pause torrent: %s', ex)

    def resume(self):
        """Resumes this torrent."""
        if self.status.paused and self.status.auto_managed:
            log.debug('Resume not possible for auto-managed torrent!')
        elif self.forced_error and self.forced_error.was_paused:
            log.debug(
                'Resume skipped for forced_error torrent as it was originally paused.'
            )
        elif (
            self.status.is_finished
            and self.options['stop_at_ratio']
            and self.get_ratio() >= self.options['stop_ratio']
        ):
            log.debug('Resume skipped for torrent as it has reached "stop_seed_ratio".')
        else:
            # Check if torrent was originally being auto-managed.
            if self.options['auto_managed']:
                self._set_handle_flags(
                    flag=lt.torrent_flags.auto_managed,
                    set_flag=True,
                )
            try:
                self.handle.resume()
            except RuntimeError as ex:
                log.debug('Unable to resume torrent: %s', ex)

        # Clear torrent error state.
        if self.forced_error and not self.forced_error.restart_to_resume:
            self.clear_forced_error_state()
        elif self.state == 'Error' and not self.forced_error:
            self.handle.clear_error()

    def connect_peer(self, peer_ip, peer_port):
        """Manually add a peer to the torrent

        Args:
            peer_ip (str) : Peer IP Address
            peer_port (int): Peer Port

        Returns:
            bool: True is successful, otherwise False
        """
        try:
            self.handle.connect_peer((peer_ip, int(peer_port)), 0)
        except (RuntimeError, ValueError) as ex:
            log.debug('Unable to connect to peer: %s', ex)
            return False
        return True

    def set_ssl_certificate(
        self,
        certificate_path: str,
        private_key_path: str,
        dh_params_path: str,
        password: str = '',
    ):
        """add a peer to the torrent

        Args:
            certificate_path(str) : Path to the PEM-encoded x509 certificate
            private_key_path(str) : Path to the PEM-encoded private key
            dh_params_path(str) : Path to the PEM-encoded Diffie-Hellman parameter
            password(str) : (Optional) password used to decrypt the private key

        Returns:
            bool: True is successful, otherwise False
        """
        try:
            self.handle.set_ssl_certificate(
                certificate_path, private_key_path, dh_params_path, password
            )
        except RuntimeError as ex:
            log.error('Unable to set ssl certificate from file: %s', ex)
            return False
        return True

    def set_ssl_certificate_buffer(
        self,
        certificate: str,
        private_key: str,
        dh_params: str,
    ):
        """add a peer to the torrent

        Args:
            certificate(str) : PEM-encoded content of the x509 certificate
            private_key(str) : PEM-encoded content of the private key
            dh_params(str) : PEM-encoded content of the Diffie-Hellman parameters

        Returns:
            bool: True is successful, otherwise False
        """
        try:
            self.handle.set_ssl_certificate_buffer(certificate, private_key, dh_params)
        except RuntimeError as ex:
            log.error('Unable to set ssl certificate from buffer: %s', ex)
            return False
        return True

    def move_storage(self, dest):
        """Move a torrent's storage location

        Args:
            dest (str): The destination folder for the torrent data

        Returns:
            bool: True if successful, otherwise False

        """
        dest = decode_bytes(dest)

        if not os.path.exists(dest):
            try:
                os.makedirs(dest)
            except OSError as ex:
                log.error(
                    'Could not move storage for torrent %s since %s does '
                    'not exist and could not create the directory: %s',
                    self.torrent_id,
                    dest,
                    ex,
                )
                return False

        try:
            # lt needs utf8 byte-string. Otherwise if wstrings enabled, unicode string.
            # Keyword argument flags=2 (dont_replace) dont overwrite target files but delete source.
            try:
                self.handle.move_storage(dest.encode('utf8'), flags=2)
            except TypeError:
                self.handle.move_storage(dest, flags=2)
        except RuntimeError as ex:
            log.error('Error calling libtorrent move_storage: %s', ex)
            return False
        self.moving_storage_dest_path = dest
        self.update_state()
        return True

    def save_resume_data(self, flush_disk_cache=False):
        """Signals libtorrent to build resume data for this torrent.

        Args:
            flush_disk_cache (bool): Avoids potential issue with file timestamps
                and is only needed when stopping the session.

        Returns:
            None: The response with resume data is returned in a libtorrent save_resume_data_alert.

        """
        if log.isEnabledFor(logging.DEBUG):
            log.debug('Requesting save_resume_data for torrent: %s', self.torrent_id)
        flags = lt.save_resume_flags_t.flush_disk_cache if flush_disk_cache else 0
        # Don't generate fastresume data if torrent is in a Deluge Error state.
        if self.forced_error:
            component.get('TorrentManager').waiting_on_resume_data[
                self.torrent_id
            ].errback(UserWarning('Skipped creating resume_data while in Error state'))
        else:
            self.handle.save_resume_data(flags)

    def write_torrentfile(self, filedump=None):
        """Writes the torrent file to the state dir and optional 'copy of' dir.

        Args:
            filedump (str, optional): bencoded filedump of a torrent file.

        """

        def write_file(filepath, filedump):
            """Write out the torrent file"""
            log.debug('Writing torrent file to: %s', filepath)
            try:
                with open(filepath, 'wb') as save_file:
                    save_file.write(filedump)
            except OSError as ex:
                log.error('Unable to save torrent file to: %s', ex)

        filepath = os.path.join(get_config_dir(), 'state', self.torrent_id + '.torrent')

        if filedump is None:
            lt_ct = lt.create_torrent(self.torrent_info)
            filedump = lt.bencode(lt_ct.generate())

        write_file(filepath, filedump)

        # If the user has requested a copy of the torrent be saved elsewhere we need to do that.
        if self.config['copy_torrent_file']:
            if not self.filename:
                self.filename = self.get_name() + '.torrent'
            filepath = os.path.join(self.config['torrentfiles_location'], self.filename)
            write_file(filepath, filedump)

    def delete_torrentfile(self, delete_copies=False):
        """Deletes the .torrent file in the state directory in config"""
        torrent_files = [
            os.path.join(get_config_dir(), 'state', self.torrent_id + '.torrent')
        ]
        if delete_copies and self.filename:
            torrent_files.append(
                os.path.join(self.config['torrentfiles_location'], self.filename)
            )

        for torrent_file in torrent_files:
            log.debug('Deleting torrent file: %s', torrent_file)
            try:
                os.remove(torrent_file)
            except OSError as ex:
                log.warning('Unable to delete the torrent file: %s', ex)

    def force_reannounce(self):
        """Force a tracker reannounce"""
        try:
            self.handle.force_reannounce()
        except RuntimeError as ex:
            log.debug('Unable to force reannounce: %s', ex)
            return False
        return True

    def scrape_tracker(self):
        """Scrape the tracker

        A scrape request queries the tracker for statistics such as total
        number of incomplete peers, complete peers, number of downloads etc.
        """
        try:
            self.handle.scrape_tracker()
        except RuntimeError as ex:
            log.debug('Unable to scrape tracker: %s', ex)
            return False
        return True

    def force_recheck(self):
        """Forces a recheck of the torrent's pieces"""
        if self.forced_error:
            self.forcing_recheck_paused = self.forced_error.was_paused
            self.clear_forced_error_state(update_state=False)
        else:
            self.forcing_recheck_paused = self.status.paused

        try:
            self.handle.force_recheck()
            self.handle.resume()
            self.forcing_recheck = True
        except RuntimeError as ex:
            log.debug('Unable to force recheck: %s', ex)
            self.forcing_recheck = False
        return self.forcing_recheck

    def rename_files(self, filenames):
        """Renames files in the torrent.

        Args:
            filenames (list): A list of (index, filename) pairs.
        """
        for index, filename in filenames:
            # Make sure filename is a sanitized unicode string.
            filename = sanitize_filepath(decode_bytes(filename))
            # lt needs utf8 byte-string. Otherwise if wstrings enabled, unicode string.
            try:
                self.handle.rename_file(index, filename.encode('utf8'))
            except (UnicodeDecodeError, TypeError):
                self.handle.rename_file(index, filename)

    def rename_folder(self, folder, new_folder):
        """Renames a folder within a torrent.

        This basically does a file rename on all of the folders children.

        Args:
            folder (str): The original folder name
            new_folder (str): The new folder name

        Returns:
            twisted.internet.defer.Deferred: A deferred which fires when the rename is complete
        """
        log.debug('Attempting to rename folder: %s to %s', folder, new_folder)

        # Empty string means remove the dir and move its content to the parent
        if len(new_folder) > 0:
            new_folder = sanitize_filepath(new_folder, folder=True)

        def on_file_rename_complete(dummy_result, wait_dict, index):
            """File rename complete"""
            wait_dict.pop(index, None)

        wait_on_folder = {}
        self.waiting_on_folder_rename.append(wait_on_folder)
        for _file in self.get_files():
            if _file['path'].startswith(folder):
                # Keep track of filerenames we're waiting on
                wait_on_folder[_file['index']] = Deferred().addBoth(
                    on_file_rename_complete, wait_on_folder, _file['index']
                )
                new_path = _file['path'].replace(folder, new_folder, 1)
                try:
                    self.handle.rename_file(_file['index'], new_path.encode('utf8'))
                except (UnicodeDecodeError, TypeError):
                    self.handle.rename_file(_file['index'], new_path)

        def on_folder_rename_complete(dummy_result, torrent, folder, new_folder):
            """Folder rename complete"""
            component.get('EventManager').emit(
                TorrentFolderRenamedEvent(torrent.torrent_id, folder, new_folder)
            )
            # Empty folders are removed after libtorrent folder renames
            self.remove_empty_folders(folder)
            torrent.waiting_on_folder_rename = [
                _dir for _dir in torrent.waiting_on_folder_rename if _dir
            ]
            component.get('TorrentManager').save_resume_data((self.torrent_id,))

        d = DeferredList(list(wait_on_folder.values()))
        d.addBoth(on_folder_rename_complete, self, folder, new_folder)
        return d

    def remove_empty_folders(self, folder):
        """Recursively removes folders but only if they are empty.

        This cleans up after libtorrent folder renames.

        Args:
            folder (str): The folder to recursively check
        """
        # Removes leading slashes that can cause join to ignore download_location
        download_location = self.options['download_location']
        folder_full_path = os.path.normpath(
            os.path.join(download_location, folder.lstrip('\\/'))
        )

        try:
            if not os.listdir(folder_full_path):
                os.removedirs(folder_full_path)
                log.debug('Removed Empty Folder %s', folder_full_path)
            else:
                for root, dirs, dummy_files in os.walk(folder_full_path, topdown=False):
                    for name in dirs:
                        try:
                            os.removedirs(os.path.join(root, name))
                            log.debug(
                                'Removed Empty Folder %s', os.path.join(root, name)
                            )
                        except OSError as ex:
                            log.debug(ex)

        except OSError as ex:
            log.debug('Cannot Remove Folder: %s', ex)

    def cleanup_prev_status(self):
        """Checks the validity of the keys in the prev_status dict.

        If the key is no longer valid, the dict will be deleted.
        """
        # Dict will be modified so iterate over generated list
        for key in list(self.prev_status):
            if not self.rpcserver.is_session_valid(key):
                del self.prev_status[key]

    def _get_pieces_info(self):
        """Get the pieces for this torrent."""
        if not self.has_metadata or self.status.is_seeding:
            pieces = None
        else:
            pieces = []
            for piece, avail_piece in zip(
                self.status.pieces, self.handle.piece_availability()
            ):
                if piece:
                    # Completed.
                    pieces.append(3)
                elif avail_piece:
                    # Available, just not downloaded nor being downloaded.
                    pieces.append(1)
                else:
                    # Missing, no known peer with piece, or not asked for yet.
                    pieces.append(0)

            for peer_info in self.handle.get_peer_info():
                if peer_info.downloading_piece_index >= 0:
                    # Being downloaded from peer.
                    pieces[peer_info.downloading_piece_index] = 2

        return pieces
